--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 1afb2d99d2ce00cbbf9d48ac5917f27d06348929
Parents : bf4da5d
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-06T00:53:30-05:00
Update Toast component with swipe functionality for dismissing notifications
Changes
2 files changed, 87 insertions(+), 2 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index dd99162e..221fe5f6 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -8,8 +8,14 @@
<div
v-for="toast in toasts"
:key="toast.id"
- class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300"
- :class="toastClass(toast.type)"
+ ref="toastRefs"
+ class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300 select-none touch-pan-y"
+ :class="[toastClass(toast.type), toast.swipeClass]"
+ :style="toastSwipeStyle(toast)"
+ @touchstart="onTouchStart($event, toast)"
+ @touchmove="onTouchMove($event, toast)"
+ @touchend="onTouchEnd(toast)"
+ @touchcancel="onTouchEnd(toast)"
>
<!-- icon -->
<div class="mr-3 shrink-0">
@@ -66,6 +72,7 @@ export default {
return {
toasts: [],
counter: 0,
+ swipeThreshold: 100,
};
},
mounted() {
@@ -125,6 +132,9 @@ export default {
type: toast.type || "info",
duration: toast.duration !== undefined ? toast.duration : 5000,
timer: null,
+ _swipeX: 0,
+ _swiping: false,
+ swipeClass: "",
};
if (newToast.duration > 0) {
@@ -157,6 +167,39 @@ export default {
return "bg-white/90 dark:bg-zinc-900/90 border-blue-500/30";
}
},
+ toastSwipeStyle(toast) {
+ const x = toast._swipeX || 0;
+ if (x === 0) return {};
+ return {
+ transform: `translateX(${x}px)`,
+ transition: toast._swiping ? "none" : "transform 0.3s ease",
+ opacity: `${1 - Math.min(Math.abs(x) / this.swipeThreshold, 0.6)}`,
+ };
+ },
+ onTouchStart(event, toast) {
+ if (event.touches.length !== 1) return;
+ toast._startX = event.touches[0].clientX;
+ toast._swipeX = 0;
+ toast._swiping = true;
+ toast.swipeClass = "";
+ },
+ onTouchMove(event, toast) {
+ if (!toast._swiping || event.touches.length !== 1) return;
+ const deltaX = event.touches[0].clientX - toast._startX;
+ toast._swipeX = deltaX;
+ },
+ onTouchEnd(toast) {
+ if (!toast._swiping) return;
+ toast._swiping = false;
+ if (Math.abs(toast._swipeX) >= this.swipeThreshold) {
+ toast.swipeClass = toast._swipeX > 0 ? "toast-swipe-out-right" : "toast-swipe-out-left";
+ this.$nextTick(() => {
+ setTimeout(() => this.remove(toast.id), 250);
+ });
+ } else {
+ toast._swipeX = 0;
+ }
+ },
},
};
</script>
@@ -182,4 +225,14 @@ export default {
transform: translateX(30px);
}
}
+.toast-swipe-out-left {
+ transform: translateX(-120%) !important;
+ opacity: 0 !important;
+ transition: transform 0.25s ease, opacity 0.25s ease !important;
+}
+.toast-swipe-out-right {
+ transform: translateX(120%) !important;
+ opacity: 0 !important;
+ transition: transform 0.25s ease, opacity 0.25s ease !important;
+}
</style>
diff --git a/tests/frontend/Toast.test.js b/tests/frontend/Toast.test.js
index ddc3a4d1..8d3f7b48 100644
--- a/tests/frontend/Toast.test.js
+++ b/tests/frontend/Toast.test.js
@@ -107,4 +107,36 @@ describe("Toast.vue", () => {
expect(cls).toContain("max-sm:bottom-");
expect(cls).not.toContain("max-sm:bottom-[calc(5.75rem");
});
+
+ it("dismisses toast on horizontal swipe past threshold", async () => {
+ GlobalEmitter.emit("toast", { message: "Swipe me", duration: 0 });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Swipe me");
+ const toastVm = wrapper.vm.toasts[0];
+
+ // Simulate swipe right by 120px (past 100px threshold)
+ wrapper.vm.onTouchStart({ touches: [{ clientX: 100, clientY: 50 }] }, toastVm);
+ wrapper.vm.onTouchMove({ touches: [{ clientX: 220, clientY: 50 }] }, toastVm);
+ wrapper.vm.onTouchEnd(toastVm);
+ await vi.advanceTimersByTimeAsync(300);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).not.toContain("Swipe me");
+ });
+
+ it("snaps toast back when swipe is below threshold", async () => {
+ GlobalEmitter.emit("toast", { message: "Stay", duration: 0 });
+ await wrapper.vm.$nextTick();
+
+ const toastVm = wrapper.vm.toasts[0];
+
+ wrapper.vm.onTouchStart({ touches: [{ clientX: 100, clientY: 50 }] }, toastVm);
+ wrapper.vm.onTouchMove({ touches: [{ clientX: 140, clientY: 50 }] }, toastVm);
+ wrapper.vm.onTouchEnd(toastVm);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Stay");
+ expect(toastVm._swipeX).toBe(0);
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────